在ASP.NET的middleware如下:
圖片來源:https://learn.microsoft.com/en-us/aspnet/core/fundamentals/middleware/
而在rust裡因為不是物件導向的概念,而我們使用的warp又比較接近函數式的寫法,又要再提到鐵道開發法了:
圖片來源:https://www.slideshare.net/ScottWlaschin/railway-oriented-programming
上面那條線是 Success ,下面那條路是 Failure,寫到這邊是不是有印象一直看到:
impl Filter<Extract=(impl warp::Reply, ), Error=Rejection>
Filter
就是warp裡實作的通道,Extract
就是上面的通道,Error
就是走下面的通道,而這個Extract
還可以往後面的通道加一點料,後面的範例會提及。impl warp::Reply
就是最後會轉成http Response的東西,而Rejection
就是放置錯誤用的物件。回憶一下在第10篇我們使用warp的recover
去接 rejection
,並把rejection
轉為我們要回應的response傳給API呼叫者:
// web/src/error.rs
pub async fn handle_rejection(err: Rejection)
-> Result<impl Reply, Infallible> {
// ... 略
Ok(warp::reply::with_status(json, code))
}
// web/src/routers.rs
pub fn all_routers(ctx: AppContext)
-> impl Filter<Extract=impl Reply, Error=Rejection> + Clone {
// ...略
hello
.or(static_files)
.or(ws_routers(ctx.clone()))
.or(api_games)
.recover(error::handle_rejection)
}
對照上面的程式碼和下面的鐵道圖,是不是有比較清晰一點了。因為我們http api最終還是要回應給http client,所以最後需要再針對Rejection
進行處理,轉成對應的Http Status Code和內容,有別於開頭C#的middleware圖,在Response時會再遍歷每一層middleware,在鐵道中就是單行道,所以當發生錯誤時,就走下面的快速通道,直達最後。這也是為什麼我們過去常常要寫錯誤的轉換:impl From<ErrorA> for ErrorB
。許多外部套件都有使用自己的Error
類別,而我們的程式也有我們自己的Error
類別,像Rejection
就是一種Error
類別,我們之前在寫gRPC用的tonic套件也有Error
類別Status
。
圖片來源:https://www.slideshare.net/ScottWlaschin/railway-oriented-programming
Middleware在我們寫rust的時候比較像上圖的鐵道,只要Input型別與前一手的Output型別一樣,Output型別與下一手的Input型別一樣,就可以安插進去,如果整路的鐵道Input/Output類型都相同,那麼就可以很容易的抽換,在FP中,也很容易對每一段獨立進行單元測試。這個概念理解後,對於在rust中使用Option和Result會更得心應手。
加 auth layer之前,講一下Role-based access control,以角色作為權限控制的單位,定義角色擁有什麼權限,而使用者可分配其歸屬的角色為何:
我們依以上的圖,簡略地開一下相關的結構體:
// core/src/user.rs
use serde::{Deserialize, Serialize};
pub struct User {
pub name: String,
}
pub struct Role {
pub name: String,
pub users_name: Vec<String>,
pub permissions: Vec<Permission>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Permission {
Admin = 0,
GameCreate = 1,
GamePlay = 2,
GameDelete = 3,
}
// core/src/lib.rs
pub mod user;
這邊可以看到rust的enum可以給予整數編號,不指定的話rust預設會使用isize幫我們針對enum裡的每一個變體(Variant)給予一個獨立的編號。而我們也可以手動給予編號,在這裡的考量是:Permission最終還是會儲存在db裡,如果用String的型式就像JSON一樣,那麼調整enum變體的順序也沒關係;但如果序列化存成數字儲存,那麼改變順序的話,從db讀出來就會產生問題。所以在這邊我們才要明確的宣示,告訴rust我們要指定每一個variant使用哪個數字作為rust程式執行時的內部編碼。
這裡要寫權限驗證,就先跳過user/role/permission的增刪查改實作,直接做一個假的權限查詢器:
// core/src/user.rs
#[deprecated(note = "this is not for production use")]
pub fn fake_query_user_permissions(user_name: String) -> Vec<Permission> {
match user_name.as_str() {
"admin" => vec![Permission::Admin, Permission::GameCreate, Permission::GamePlay, Permission::GameDelete],
"game" => vec![Permission::GameCreate, Permission::GamePlay],
_ => vec![],
}
}
除了它是假的,這裡fn上面還有一個deprecated的annotation標註,這是什麼作用呢,就是在compile的時候抱怨,提醒這個fn要被棄用了,除了note
外還可以下 since
預告哪一個版本之後將移除此fn
,如果在寫比較底層的api供別人使用。這個在做版本管理滿實用的,讓別人可以及早因應。而除了編譯的抱怨,IDE也會逼得我們不得不注意到:
compiler:
VS Code:
RustRover
其實deprecated各程式語言都有相對應的用法:C# Obselte、Java Deprecated、TypeScript deprecated。
以下我們先實作登入產生token,再實作呼叫api帶JWT如何驗證權限。
還是上面那張鐵道圖來理解,request請求打進來,我們要解析http request body的內容,就要加一個function處理,每一個and()
裡包的都是一個小型的middleware,先看code再說明:
// web/src/auth.rs
use std::{env, ops::Add};
use warp::{Filter, Rejection, Reply};
use my_core::user::{fake_query_user_permissions};
pub fn login() -> impl Filter<Extract=(impl Reply, ), Error=Rejection> + Clone {
warp::path!("login")
.and(warp::post())
.and(warp::body::content_length_limit(1024 * 16))
.and(warp::body::json::<LoginRequest>())
.and_then(login_handler)
}
#[derive(Debug, Clone, Deserialize)]
pub struct LoginRequest { // Request Dto
pub username: String,
pub password: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct LoginResponse { // Response Dto
pub access_token: String,
}
warp::body::content_length_limit
:這個鐵道部件是warp幫我們做好的(好有小時候玩鐵道組的既視感),接上去會判斷http請求的header帶的Content-Length多少,超過的就會拒絕此請求,傳出Rejection
。(此項目不是必填)。warp::body::json
:這個fn會幫我們判斷請求檔頭是不是有Content-Type: application/json,不是就拒絕此請求,是的話就試著把body的json內容解析為我們指定的類別T
,並傳給下一組鐵道組件。接下來處理接到request的邏輯:
pub async fn login_handler(req: LoginRequest) // 由上一個部件傳入
-> Result<impl Reply, Rejection> {
let sub = req.username;
if sub == "guest" { // todo: 待實作驗 user 帳密 (pass hash)
return Err(Rejection::from( // 模擬登入失敗
AppError::BadRequest("登入失敗".to_string())))
}
let permissions = fake_query_user_permissions(sub.clone());
let permissions: Vec<u16> = permissions
.iter()
.map(|p| p.clone().into())
.collect::<Vec<_>>(); // 把 permission 轉整數
let exp = Utc::now()
.add(chrono::Duration::hours(8)) // 加8小時到期
.timestamp();
let claim = Claims { // 產jwt用的payload
sub,
exp,
permissions,
};
let access_token = generate_jwt(key(), claim)?;
let response = LoginResponse { access_token };
Ok::<_, Rejection>(warp::reply::json(&response))
}
這裡我們login_handler
加一個參數類別是LoginRequest
,這參數必需是從前一個鐵道部分傳過來的,不然會報錯,前後銜接要相同類別才能接的起來,最後再把運算的結果轉成回應DTO,並使用warp::reply::json把資料序列化為JSON。
最後把這一整組軌道部件加到我們的router裡:
// web/src/routers.rs
use crate::auth::login;
pub fn all_routers(ctx: AppContext)
// ... 略
hello
.or(login())
// ... 略
實測一下:
一般用戶成功登入,權限為空:
ADMIN 登入,含所有權限:
登入失敗:
依照剛剛做鐵道部件的經驗,加一個解析token的fn,並把解析完的結果,放入一個叫CurrentUser
的結構體:
// web/src/auth.rs
use warp::header::headers_cloned;
use warp::http::{HeaderMap, HeaderValue};
use my_core::user:: Permission;
pub fn with_auth() // ↓↓ Extract取出來的類別CurrentUser當成後面的參數
-> impl Filter<Extract=(CurrentUser, ), Error=Rejection> + Clone {
headers_cloned().and_then(|headers: HeaderMap<HeaderValue>|
async move {
let auth_header = headers.get("Authorization");
match auth_header {
None => {
return Ok::<_, Rejection>(CurrentUser::Anonymous)
}
Some(auth_header) => {
let token = auth_header.to_str().unwrap().to_string();
let jwt = token.replace("Bearer ", "");
let claims = verify_jwt(key(), jwt)?;
let permissions = claims.permissions;
let name = claims.sub;
Ok::<_, Rejection>(
CurrentUser::User { name, permissions })
}
}
})
}
#[derive(Debug, Clone)]
pub enum CurrentUser {
Anonymous, // 匿名使用者(無登入資訊)
User { // 使用者
name: String, // 使用者帳號
permissions: Vec::<u16>, // 使用者權限清單
},
}
這邊加一個CurrentUser
結構體,用來存放當前使用者的資料,在with_auth
中我們使用warp提供的組件headers_cloned(),會試著解析請求的header,並當參數傳出,所以我們後面的and_then
就要接收這個header,裡面就解析header裡的Authorization
欄位,並把解析的結果存入CurrentUser
,遞交給下一棒。
and
只是加料,and_then
才會接受累加的參數,而and_then
裡面要放一個Future
,所以我們加上async
讓閉包回傳一個Future
。而取得JWT驗出來使用者資訊後,接下來我們要做一個軌道,用來以後針對個別API檢驗權限使用(大家開始除了組積木外,也會自己造積木了):
/// 檢查該使用者是不是擁有指定權限
pub fn check_permission(user: CurrentUser, permission: Permission)
-> Result<(), Rejection> {
let permission: u16 = permission.into();
match user {
CurrentUser::Anonymous => {
Err(warp::reject::custom(AppError::Unauthorized))
}
CurrentUser::User { name, permissions } => {
if permissions.iter().any(|&p| p == permission) {
Ok::<_, Rejection>(())
} else {
Err(warp::reject::custom(AppError::Unauthorized))
}
}
}
}
上面只是單純檢核指定的User有沒有包含指定的權限Permission
,使用iter().any()
進行搜索,成功回傳()
,失敗則傳出我們定義的AppError::Unauthorized
,並透過warp轉為Rejection
。以下是我們最終版的積木:
pub fn with_permission(permission: Permission)
-> impl Filter<Extract=(), Error=Rejection> + Clone {
with_auth()
.and_then(move |user: CurrentUser| {
let p = permission.clone();
async {
let result = check_permission(user, p);
match result {
Ok(_) => {
Ok::<_, Rejection>(())
}
Err(_) => {
Err(warp::reject::custom(
AppError::Unauthorized))
}
}
}
}).untuple_one()
}
最後這塊積木我們讓它Extract
回傳()
,就表示我們沒有在後面的管道添加新的東西,因為只要一加了東西,我們先前寫的api,就要添加對應的參數。比如前面的with_auth()
只要被套用,後面處理的handler就要再多接一個CurrentUser
的參數,這樣好像會讓程式變的不太彈性,所以with_permission
最後接一個untuple_one,把我們裡面的結果再凹成沒有回傳,就是不往後面的軌道裡加料。
一樣在and_then
裡因為要回傳一個Future
,所以加async,而這裡的所有權限核機制,參數permission
不允許被move進閉包,所以我們在閉包裡把permission
克隆(clone)一份,保留不移動外面的所有權。
最後怎麼用這塊積木呢,我們直接接在hello
上實驗一下:
// web/src/routers.rs
use my_core::user::Permission;
use crate::auth::{login, with_permission};
pub fn all_routers(ctx: AppContext)
// ... 略
let hello = warp::path("hello")
.and(warp::get())
.and(with_permission(Permission::Admin)) // 加這行
.map(|| {
tracing::info!("saying hello...");
"Hello, World!"
});
// ... 略
實測一下,利用剛剛產出的JWT,帶入API中:
ADMIN:
非ADMIN:
雖然warp有實作trace request,我們在第9篇就加到我們的專案裡了,不過這邊可以看一下如果我們要自己做的話大約是怎麼實現的,順便再熟悉一下剛剛鐵道的加料方法:
use std::net::SocketAddr;
use warp::http::{HeaderMap, Method};
use warp::hyper::body::Bytes;
use warp::path::FullPath;
pub fn all_routers(ctx: AppContext) {
// ... 略
let hello = warp::path("hello")
.and(warp::get())
.and(with_permission(Permission::Admin))
.and(tracing()) // 加這
.map(|| {
tracing::info!("saying hello...");
"Hello, World!"
});
// ...略
}
fn tracing() -> impl Filter<Extract=(), Error=Rejection> + Clone {
warp::addr::remote()
.and(warp::header::headers_cloned())
.and(warp::method())
.and(warp::path::full())
.and(warp::query::raw())
.and(warp::body::bytes())
.and_then(|addr:Option<SocketAddr> , headers:HeaderMap, method:Method, path:FullPath, query: String, body:Bytes| async move {
let query = query.to_string();
let body = String::from_utf8(body.to_vec()).unwrap_or_default();
tracing::warn!(
"addr: {:?}\nmethod: {:?}\npath: {:?}\nquery: {:?}\nheaders: {:?}\nbody: {:?}",
addr,
method,
path,
query,
headers,
body
);
Ok::<(), Rejection>(())
})
.untuple_one()
}
可以看到這裡我們加了很多料,所以在and_then()
在接的時候,就要把上面一連串加的東西全部都帶進來,不加或漏加的話會報錯,而最後我們依然使用untuple_one
,讓一切塵歸塵土歸土(?),悄悄地來悄悄地走,不留下一片雲彩(?),喔我可能累了,就是最後不產生副作用去影響後續的handler,這邊幾乎吧request可以取得的資料全用上了,如果有需要可以自己挑去使用。
實測一下
呼叫後端API:
只要改以下,我們就可以使用IPv6的協議連線了:
@@ web/src/main.rs @@
+use std::net::IpAddr;
+use std::str::FromStr;
+let addr = IpAddr::from_str("::0").unwrap();
+warp::serve(routers).run((addr, config::http_port())).await;
-warp::serve(routers).run(([0, 0, 0, 0], config::http_port())).await;
使用IPv4 連線:
後端接到IPv4 的訊息:
使用IPv6連線:
後端接到IPv6 的訊息:
不過這個方式依這裡面的說明會依不同的OS而有所不同,不總是可以作用,另一種方式是開多個port同時跑,我們已經學過tokio的分身之術,在這裡就可以直接開起來:
let addr_v6 = IpAddr::from_str("::0").unwrap();
let addr_v4 = [0,0,0,0];
tokio::join!(
warp::serve(routers.clone()).run((addr_v4, config::http_port())),
warp::serve(routers.clone())
.tls()
.cert_path(config::tls_cert_path())
.key_path(config::tls_key_path())
.run((addr_v4, config::https_port())),
warp::serve(routers.clone()).run((addr_v6, 3036)),
warp::serve(routers.clone())
.tls()
.cert_path(config::tls_cert_path())
.key_path(config::tls_key_path())
.run((addr_v6, 3037)),
);
上例我們使用tokio的join
巨集,把ipv4, ipv6, http, https等服務跑在同一個執行檔裡。
本系列專案源始碼放置於 https://github.com/kenstt/demo-app